while ((c = fgetc(src_fp)) != EOF) { if (c == ' ' || c == '\t' || c == '\n') continue; if (i >= sizeof(buffer)) { // 꽉 차면 파일에 쓰고 비운다 if (fwrite(buffer, 1, i, dst_fp) != i) goto CLEAN; i = 0; } buffer[i++] = c;}if (i > 0 && fwrite(buffer, 1, i, dst_fp) != i) goto CLEAN; // 남은 만큼
입력 크기 0, 1023, 1024, 1025, 3000바이트 경계 케이스에서 모두 출력 크기가 입력과 일치했다.
TIP
fwrite(ptr, size, nmemb, fp)의 2번째 인자는 원소 하나의 크기, 3번째는 개수다. 반환값은 쓴 원소 개수라서, 바이트 버퍼는 size = 1로 두는 것이 관례다. fwrite(buf, 100, 1, fp)는 50바이트만 써져도 0을 반환해 얼마나 썼는지 알 수 없다.
5. 응용 2 — 안전하게 저장하기 (fio_ex_v2)
조건을 추가해 다시 만들었다.
인자로src, dst를 받는다
src/dst의 파일 상태를 확인하고, 디렉터리면 에러
임시 파일에 쓴 뒤 rename 으로 교체
반환값을 모두 검사해서 예외 처리
에러 처리는 goto로 한 곳에 모으기
fclose 시 버퍼를 비우며 생기는 오류도 처리
5-1. 파일 상태 확인 — stat
요약
문제: src/dst가 디렉터리·특수 파일·같은 파일일 수 있음
원인: dst가 없는 것은 정상, 그 외 이유의 stat 실패는 오류
처방: stat + S_ISDIR/S_ISREG, st_dev+st_ino가 같으면 같은 파일
상세 보기
if (stat(src_file_name, &src_st) != 0) { perror("stat src"); goto CLEAN; }if (S_ISDIR(src_st.st_mode)) { fprintf(stderr, "src is a directory\n"); goto CLEAN; }if (!S_ISREG(src_st.st_mode)) { fprintf(stderr, "src is not a regular file\n"); goto CLEAN; }if (stat(dst_file_name, &dst_st) == 0) { if (S_ISDIR(dst_st.st_mode)) { /* dst가 디렉터리 */ goto CLEAN; } if (!S_ISREG(dst_st.st_mode)) { /* 장치 파일 등 */ goto CLEAN; } if (src_st.st_dev == dst_st.st_dev && src_st.st_ino == dst_st.st_ino) { /* src와 dst가 같은 파일 */ goto CLEAN; }} else if (errno != ENOENT) { // 없는 것 외의 이유(권한 등)로 실패 perror("stat dst"); goto CLEAN;}
dst가 없는 건 정상이다(새로 만든다). 그 외 이유로 stat이 실패하면 에러.
st_dev + st_ino가 같으면 같은 파일이다(경로가 달라도, 하드링크여도).
5-2. 임시 파일 + rename
요약
문제: 저장 중 실패하거나 전원이 나가면 반쯤 쓰인 dst가 생김
원인: dst에 직접 쓰기 때문
처방: dst와 같은 디렉터리에 mkstemp → 쓰기 → fflush·fsync·fclose → rename
결과: 기존 dst는 그대로, 임시 파일만 삭제
flowchart TD
A["mkstemp: dst와 같은 디렉터리에 임시 파일 생성"] --> B["fchmod: src 권한 복사"]
B --> C["임시 파일에 모두 쓰기"]
C --> D["fflush → fsync → fclose<br/>반환값 검사"]
D --> E{"모두 성공?"}
E -- "예" --> F["rename(tmp, dst)<br/>원자적 교체"]
E -- "아니오" --> G["임시 파일 삭제<br/>기존 dst는 그대로"]
상세 보기
n = snprintf(tmp_name, sizeof(tmp_name), "%s.tmpXXXXXX", dst_file_name); // dst와 같은 디렉터리tmp_fd = mkstemp(tmp_name); // 0600, O_EXCL로 생성하고 tmp_name을 실제 이름으로 바꿈tmp_created = 1;fchmod(tmp_fd, src_st.st_mode & 0777); // src와 같은 권한으로/* ... 임시 파일에 모두 쓴다 ... */fflush(tmp_fp);fsync(fileno(tmp_fp)); // 디스크 반영fclose(tmp_fp); // 반환값 검사rename(tmp_name, dst_file_name); // 같은 파일시스템이므로 원자적 교체tmp_created = 0;
도중에 실패하면 기존 dst는 그대로이고 임시 파일만 지운다. 쓰다가 전원이 나가도 반쯤 쓰인 dst가 생기지 않는다.
임시 파일을 dst와 같은 디렉터리에 만드는 이유는 rename이 같은 파일시스템 안에서만 원자적이기 때문이다.
이미 있는 일반 파일은 rename이 덮어쓴다. 덮어쓰기를 막으려면 dst가 존재하는 순간 에러로 처리하면 된다.
5-3. goto 정리와 fclose의 반환값
요약
문제: 쓰기 오류가 fwrite가 아니라 fflush·fsync·fclose에서 드러남, fdopen 뒤 close(fd) 중복
원인: 디스크가 꽉 차도 fwrite는 버퍼에만 쓰고 성공할 수 있음
처방: 비우는 시점의 반환값 검사, fdopen 성공 직후 fd = -1, 열려 있는 것만 닫는 CLEAN 구간
상세 보기
/* ... 중간에 실패하면 goto CLEAN ... */ n = fclose(tmp_fp); tmp_fp = NULL; // 실패해도 이미 닫힌 상태라 다시 닫지 않도록 if (n != 0) { perror("fclose tmp"); goto CLEAN; } if (rename(tmp_name, dst_file_name) != 0) { perror("rename"); goto CLEAN; } tmp_created = 0; ret = 0;CLEAN: if (src_fp != NULL) { if (fclose(src_fp) != 0) { perror("fclose src"); ret = -1; } } else if (src_fd >= 0) close(src_fd); if (tmp_fp != NULL) { if (fclose(tmp_fp) != 0) perror("fclose tmp"); ret = -1; } else if (tmp_fd >= 0) close(tmp_fd); if (tmp_created) unlink(tmp_name); // 실패했으면 임시 파일 삭제 return ret;
쓰기 오류는 fwrite가 아니라 fflush·fsync·fclose에서 드러날 수 있다. 디스크가 꽉 찬 경우 fwrite는 버퍼에만 쓰고 성공할 수 있기 때문에, 비우는 시점의 반환값을 반드시 확인한다.
fdopen에 성공한 뒤에는 fclose가 내부 fd까지 닫으므로close(fd)를 또 부르면 안 된다. 그래서 fdopen 성공 직후 fd = -1로 되돌려 놓는다.
정리 구간에서는 열려 있는 것만 닫는다 (모든 포인터를 NULL/-1로 초기화).
5-4. 테스트 결과
테스트 시나리오 표
시나리오
결과
정상
공백이 모두 제거된 출력, 권한 644
기존 dst가 있음
내용 교체
인자 없음 / src 없음
에러 메시지, 종료 코드 255
src가 디렉터리 / dst가 디렉터리
에러 메시지
src와 dst가 같은 파일
에러 메시지
dst의 디렉터리가 없음
mkstemp: No such file or directory
3000바이트 입력
출력도 3000바이트
실패한 모든 경우
임시 파일이 남지 않음
NOTE
stat·open·mkstemp·fsync·fchmod·fdopen·unlink는 POSIX 함수다. rename·fopen·fflush는 ISO C 표준이다. 그래서 이 코드는 Linux에서는 그대로 빌드되지만, 순수 표준 C 환경이나 MSVC에서는 일부를 바꿔야 한다.
6. 응용 3 — 줄 번호 붙여 저장 (fio_ex2)
요약
문제: 줄 번호를 붙여 저장하는 fio_ex2의 10가지 오류
원인: while (… == EOF) 반대 조건, char out, 문자 수를 줄 수로 셈, fseek 누락 등
처방: 첫 읽기에서 줄 수·최대 길이 → fseek로 되감기 → 두 번째 읽기에서 %*d로 번호 정렬
상세 보기
src를 읽어 전체 줄 수와 가장 긴 줄의 길이를 헤더에 쓰고, 각 줄 앞에 자릿수에 맞춰 정렬된 줄 번호를 붙여 dst에 저장한다.
전체 줄 수 : 12, 가장 긴 줄 수 : 11
1 : line 1 abc
2 : line 2 abc
...
10 : line 10 abc
12 : line 12 abc
두 번 읽는 구조이므로 첫 번째 읽기에서 줄 수·최대 길이를 구하고, fseek(src_fp, 0L, SEEK_SET)으로 처음으로 돌아가 한 번 더 읽는다.
처음 짠 코드에서 고친 점들은 다음과 같다.
문제
설명
루프 조건이 반대
while ((out = fgetc(fp)) == EOF)는 EOF일 때만 도는 루프. != EOF
char out
fgetc는 int를 반환한다. char면 EOF와 구분되지 않을 수 있음
줄 수 계산
문자마다 body_len++는 줄 수가 아니라 문자 수. '\n'을 만났을 때만 증가
최대 길이
줄이 끝날 때마다 현재 길이를 항상 0으로 리셋하고 최댓값만 갱신
마지막 줄
개행 없이 끝나면 EOF 이후 한 번 더 반영
두 번째 읽기
처음으로 되돌리는 fseek 누락
줄 번호
0이 아니라 1부터, 폭은 전체 줄 수의 자릿수로 %*d
번호 위치
개행 뒤가 아니라 “줄의 첫 글자일 때만” 찍어 마지막 개행 뒤에 빈 번호가 안 생기게
열기
src에 O_CREAT|O_EXCL을 쓰던 것을 O_RDONLY로
마무리
fclose·fflush·ferror 검사 누락
width = 1;for (out = total_lines; out >= 10; out /= 10) width++; // 자릿수 계산at_line_start = 1;while ((out = fgetc(src_fp)) != EOF) { if (at_line_start) { // 줄의 첫 글자일 때만 번호 fprintf(dst_fp, "%*d : ", width, current_line++); at_line_start = 0; } fputc(out, dst_fp); if (out == '\n') at_line_start = 1;}
💡 보너스 — man 페이지에서 C 함수 찾기
상세 보기 (man 섹션, vim K)
stat·rename은 쉘 명령어도 있고 C 함수도 있다. man stat은 섹션 번호가 낮은 쉘 명령어(1)를 먼저 보여준다.
섹션
내용
1
쉘 명령어
2
시스템 콜 (stat(2), open(2))
3
C 라이브러리 함수 (fopen(3))
man 2 stat # 시스템 콜man 3 fopen # C 라이브러리
vim에서는 단어 위에 커서를 두고 Shift+K를 누르면 man <단어>가 열리는데, 이때 앞에 숫자를 붙여 숫자 + Shift+K (예: 2K)를 누르면 해당 섹션의 man 페이지가 열린다.
그냥 K는 쉘 명령어(섹션 1)만 보여주므로, C 함수를 볼 때는 2K(시스템 콜), 3K(라이브러리 함수)를 쓴다.
TIP
gdb로 디버깅하려면 gcc에 -g 옵션을 주고 빌드해야 한다 (gcc -g -O0 ...). 자세한 내용은 문자열 함수 실습 노트의 “실습 환경 팁” 참고.
✅ 정리 — 파일 I/O 점검 체크리스트
체크리스트 펼치기
열기 조건이 의도와 맞는가 ("w"는 덮어쓰기, 새 파일만 필요하면 "wx" / O_EXCL)
반환값을 모두 검사했는가 (fopen·fdopen·fseek·fwrite·fflush·fclose·rename)
fseek의 실패는 반환값 -1, 원인은 errno로 구분하는가
읽기 함수의 반환값(fgetc→int, fgets→NULL)으로 루프를 판단하는가 (feof 루프 금지)